luci-mod-system: Add repo key management
authorPaul Donald <[email protected]>
Tue, 27 May 2025 15:11:25 +0000 (17:11 +0200)
committerPaul Donald <[email protected]>
Sat, 31 May 2025 10:12:06 +0000 (12:12 +0200)
Simplifies adding third-party repos. Functions on both 24.10 (opkg) and
main (apk). Based loosely on sshkey management.

Signed-off-by: Paul Donald <[email protected]>
modules/luci-mod-system/htdocs/luci-static/resources/view/system/repokeys.js [new file with mode: 0644]
modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json
modules/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json

diff --git a/modules/luci-mod-system/htdocs/luci-static/resources/view/system/repokeys.js b/modules/luci-mod-system/htdocs/luci-static/resources/view/system/repokeys.js
new file mode 100644 (file)
index 0000000..b502677
--- /dev/null
@@ -0,0 +1,213 @@
+'use strict';
+'require baseclass';
+'require view';
+'require fs';
+'require ui';
+
+const APK_DIR = '/etc/apk/keys/';
+const OPKG_DIR = '/etc/opkg/keys/';
+const isReadonlyView = !L.hasViewPermission() || null;
+
+let KEYDIR = null;
+let KEYEXT = null;
+
+/* This safeList is not bullet-proof, but should prevent users
+       accidentally deleting official repo keys */
+const safeList = [
+       'd310c6f2833e97f7', // 24.10 release usign key
+       'openwrt-snapshots.pem', // main snapshots EC pub key
+];
+
+function isFileInSafeList(file){
+       for (name of safeList) {
+               if (file === name)
+                       return true;
+       }
+       return false;
+}
+
+function normalizeKey(s) {
+       return s.replace(/\s+/g, ' ').trim();
+}
+
+function determineKeyEnv() {
+       return fs.stat(APK_DIR).then(() => {
+               KEYDIR = APK_DIR;
+               KEYEXT = '.pem'; // not strictly necessary - apk allows any extension
+       }).catch(() => {
+               KEYDIR = OPKG_DIR;
+               KEYEXT = null; // opkg requires key filenames without an extension
+       });
+}
+
+function listKeyFiles() {
+       return fs.list(dir).then(entries =>
+               Promise.all(entries.map(entry =>
+                       fs.read(KEYDIR + entry.name).then(content => ({
+                               filename: entry.name,
+                               key: content
+                       }))
+               ))
+       );
+}
+
+function renderKeyItem(pubkey) {
+       const safeFile = isFileInSafeList(pubkey?.filename);
+       const lines = pubkey?.key?.trim()?.split('\n').map(line =>
+               [ E('br'), E('code', line) ]
+       ).flat();
+       return E('div', {
+               class: 'item',
+               click: (isReadonlyView || safeFile) ? null : removeKey,
+               'data-file': pubkey?.filename,
+               'data-key': normalizeKey(pubkey?.key)
+       }, [
+               E('strong', [ pubkey?.filename || _('Unnamed key') ]),
+               ...lines
+       ]);
+}
+
+function refreshKeyList(list, keys) {
+       while (!matchesElem(list.firstElementChild, '.add-item'))
+               list.removeChild(list.firstElementChild);
+
+       keys.forEach(function(pubkey) {
+               list.insertBefore(renderKeyItem(pubkey), list.lastElementChild);
+       });
+
+       if (list.firstElementChild === list.lastElementChild)
+               list.insertBefore(E('p', _('No software repository public keys present yet.')), list.lastElementChild);
+}
+
+function saveKeyFile(keyContent, file, fileContent) {
+       const ts = Date.now();
+       // generate a file name in case key content was pasted
+       const filename = file ? file?.name?.split('.')?.[0] + (KEYEXT || '') : null;
+       const noname = 'key_' + ts + (KEYEXT || '');
+       return fs.write(KEYDIR + (filename ?? noname), fileContent ?? keyContent, 384 /* 0600 */);
+}
+
+function removeKey(ev) {
+       const file = ev.currentTarget.getAttribute('data-file');
+       const list = findParent(ev.target, '.cbi-dynlist');
+
+       L.showModal(_('Delete key'), [
+               E('div', _('Really delete the following software repository public key?')),
+               E('pre', [ file ]),
+               E('div', { class: 'right' }, [
+                       E('div', { class: 'btn', click: L.hideModal }, _('Cancel')),
+                       ' ',
+                       E('div', {
+                               class: 'btn danger',
+                               click: function() {
+                                       fs.remove(KEYDIR + file).then(() => {
+                                               return listKeyFiles().then(keys => refreshKeyList(list, keys));
+                                       });
+                                       ui.hideModal();
+                               }
+                       }, _('Delete key'))
+               ])
+       ]);
+}
+
+function addKey(ev, file, fileContent) {
+       const list = findParent(ev.target, '.cbi-dynlist');
+       const input = list.querySelector('textarea[type="text"]');
+       const key = input.value;
+
+       if (!key.length)
+               return;
+
+       // Prevent duplicates
+       const exists = Array.from(list.querySelectorAll('.item')).some(
+               item => item.getAttribute('data-key') === normalizeKey(key)
+       );
+       if (exists) {
+               ui.addTimeLimitedNotification(_('Add key'), [
+                       E('div', _('The given software repository public key is already present.')),
+               ], 7000, 'notice');
+               return;
+       }
+
+       input.value = '';
+       saveKeyFile(key, file, fileContent)
+               .then(() => listKeyFiles())
+               .then(keys => refreshKeyList(list, keys))
+               .catch(e => ui.addNotification(null, E('p', e.message)));
+}
+
+function dragKey(ev) {
+       ev.stopPropagation();
+       ev.preventDefault();
+       ev.dataTransfer.dropEffect = 'copy';
+}
+
+function dropKey(ev) {
+       ev.preventDefault();
+       ev.stopPropagation();
+
+       const input = ev.currentTarget.querySelector('textarea[type="text"]');
+
+       for (const file of ev.dataTransfer.files) {
+               const reader = new FileReader();
+               reader.onload = rev => {
+                       input.value = rev.target.result;
+                       addKey(ev, file, rev.target.result);
+                       input.value = '';
+               };
+               reader.readAsText(file);
+       }
+}
+
+function handleWindowDragDropIgnore(ev) {
+       ev.preventDefault();
+}
+
+return view.extend({
+       load() {
+               return determineKeyEnv().then(listKeyFiles);
+       },
+
+       render(keys) {
+               const list = E('div', {
+                       class: 'cbi-dynlist',
+                       style: 'max-width: 800px',
+                       dragover: isReadonlyView ? null : dragKey,
+                       drop: isReadonlyView ? null : dropKey
+               }, [
+                       E('div', { class: 'add-item' }, [
+                               E('textarea', {
+                                       id: 'key-input',
+                                       'aria-label': _('Paste or drag repository public key'),
+                                       class: 'cbi-input-text',
+                                       type: 'text',
+                                       placeholder: _('Paste or drag to upload a software repository public key…'),
+                                       keydown: function(ev) { if (ev.keyCode === 13) addKey(ev); },
+                                       disabled: isReadonlyView
+                               }),
+                               E('button', {
+                                       class: 'cbi-button',
+                                       click: ui.createHandlerFn(this, addKey),
+                                       disabled: isReadonlyView
+                               }, _('Add key'))
+                       ])
+               ]);
+
+               refreshKeyList(list, keys);
+               window.addEventListener('dragover', handleWindowDragDropIgnore);
+               window.addEventListener('drop', handleWindowDragDropIgnore);
+
+               return E('div', {}, [
+                       E('h2', _('Repository Public Keys')),
+                       E('div', { class: 'cbi-section-descr' },
+                               _('Each software repository public key (from official or third party repositories) allows packages in lists signed by it to be installed by the package manager.')),
+                       E('div', { class: 'cbi-section-descr' },
+                               _('Each key is stored as a file in <code>%s</code>.').format(KEYDIR)),
+                       E('div', { class: 'cbi-section-node' }, list)
+               ]);
+       },
+
+       handleSaveApply: null,
+       handleSave: null,
+       handleReset: null
+});
index ebae989d0e00b5771453565fb6d10243ccbe9943..0822d44845c66539f8b81ad2d8adf40ccdd3a555 100644 (file)
                }
        },
 
+       "admin/system/admin/repokeys": {
+               "title": "Repo Public Keys",
+               "order": 5,
+               "action": {
+                       "type": "view",
+                       "path": "system/repokeys"
+               },
+               "depends": {
+                       "acl": [ "luci-mod-system-repokeys" ]
+               }
+       },
 
        "admin/system/startup": {
                "title": "Startup",
index b096d870a754ae9253b477044bc5fb5f7cced518..c61338893b8595e73fe4f2b9e73bbf3663d32bf0 100644 (file)
                }
        },
 
+       "luci-mod-system-repokeys": {
+               "description": "Grant access to Software Repository Public Key management",
+               "read": {
+                       "file": {
+                               "/etc/opkg/keys/*": [ "read" ],
+                               "/etc/apk/keys/*": [ "read" ]
+                       }
+               },
+               "write": {
+                       "file": {
+                               "/etc/opkg/keys/*": [ "write" ],
+                               "/etc/apk/keys/*": [ "write" ]
+                       }
+               }
+       },
+
        "luci-mod-system-uhttpd": {
                "description": "Grant access to uHTTPd configuration",
                "read": {